Design - Theming

August 17, 2026

 

Every MoreForm, MoreUserControl, and More* control gets its colors from one place: ThemeManager (OneMore/UI/ThemeManager.cs). This doc covers that mechanism — how a theme is resolved, how it's applied to a form's control tree, and the pattern individual controls follow to opt in. It does not cover how OneNote itself remaps page content colors between light and dark canvas, or the raw Office theme registry values — that's already written up in TechNote - Colors. This doc is scoped entirely to OneMore's own dialog/control theming layer, which shares no code with it. See also Design - UI Layer for MoreForm/MoreUserControl and the modal/modeless mechanics this builds on.

 

This layer exists because OneMore renders its own WinForms UI on top of an Office ribbon whose theme (Colorful/Dark Gray/Black/White/System) is set independently of Windows' own light/dark setting, and neither one is visible to dllhost.exe through any manifest or API OneMore doesn't call explicitly. A OneMore dialog has to go decide, for itself, whether it should look light or dark.

 

Architecture

 

ThemeManager (OneMore/UI/ThemeManager.cs) is a lazy static singleton:

 

public static ThemeManager Instance => instance ??= new ThemeManager();

 

There's no DI container involved — MoreForm and MoreUserControl both cache ThemeManager.Instance into a protected readonly ThemeManager manager field in their constructors, and individual More* controls do the same. The instance loads its color table once, the first time anything touches Instance, and caches it for the life of the process.

 

ThemeManager doubles as the palette itself — there's no separate Theme class. It holds a flat Dictionary<string, Color> Colors plus a public bool DarkMode { get; private set; } flag, and exposes GetColor(string key) for controls to pull named colors out of it.

 

ThemeMode

 

internal enum ThemeMode { System, Light, Dark, User }

 

Only System, Light, and Dark are reachable through the product's own UI — GeneralSheet's theme dropdown populates exactly those three. User is defined but not wired to any settings-sheet option; see "Custom themes" below for how a user theme actually gets applied instead.

 

Resolving the active theme

 

LoadColors(int modeIndex = -1) runs this precedence, in order:

 

  1. A custom theme file, if present, always wins — regardless of the ThemeMode setting. If OneMoreTheme.json exists under the add-in's AppData folder (PathHelper.GetAppDataPath()), it's deserialized verbatim and everything below is skipped.
  1. Otherwise the mode is either the modeIndex argument (when ≥ 0) or the persisted Theme setting read from SettingsProvider.
  1. DarkMode is computed as:

 

DarkMode = !IsDesignTime &&
(mode == ThemeMode.Dark ||
(mode == ThemeMode.System && Office.IsBlackThemeEnabled(
true)));
 

Office.IsBlackThemeEnabled
(OneMore/Helpers/Office/Office.cs) reads the Office UI Theme/Theme registry value under HKCU\Software\Microsoft\Office\{ver}\Common; if Office itself is set to "System" (6), it falls through to the Windows personalization key (HKCU\...\Themes\Personalize\AppsUseLightTheme). The ignorePage: true argument ThemeManager always passes means this check deliberately ignores OneNote's own per-page "Switch Background" light override — dialog theming tracks Office's chrome theme, not the currently open page.

 

  1. Colors load from one of two embedded JSON resources —
    OneMore/UI/DarkTheme.json or LightTheme.json — via a small custom JsonConverter<Color> that accepts either a #RRGGBB hex string or a known System.Drawing color name.

 

IsDesignTime (checked via LicenseManager.UsageMode / process name) is consulted here and throughout the theming code specifically to make sure nothing ever assigns BackColor/ForeColor while a form is open in the Visual Studio designer — a plain property assignment there gets baked permanently into the generated Designer.cs, because ShouldSerializeXxx conventions aren't honored for Control.BackColor/ForeColor overrides in .NET Framework. Skipping the assignment at design time is what prevents a wrong-for-runtime color from getting hardcoded into a control's Designer.cs the next time it's opened in the designer.

 

Custom themes

There's no in-app editor. A user (or a future settings sheet) drops a JSON file — same shape as DarkTheme.json/LightTheme.json, a DarkMode bool plus a Colors map — at PathHelper.GetAppDataPath()\OneMoreTheme.json, and LoadColors picks it up unconditionally on next load. ThemeManager.HasCustomTheme() is just an File.Exists check other code can use to detect this state.

 

No live theme switching

ThemeManager loads its color table once and caches it for the process lifetime. There is no SystemEvents.UserPreferenceChanged subscription, no WM_SETTINGCHANGE handling, and no registry of currently-open forms anywhere in the theming code. A Windows or Office theme change while OneMore is running has no effect on already-open dialogs, or on newly opened ones, until the dllhost.exe COM surrogate process restarts — or until the user explicitly changes the theme dropdown in GeneralSheet, which calls ThemeManager.Instance.LoadColors(themeBox.SelectedIndex) directly. Anyone building a new dialog can treat "the theme" as a fixed input for the lifetime of that dialog; there's no live-update case to handle.

 

Applying a theme to a form

 

Two independent recursive tree walks run when a MoreForm loads, triggered from manager.InitializeTheme(this) in MoreForm.OnLoad:

 

  1. Colorize(Control control) — assigns BackColor/ForeColor directly. Parent first, then recurses into control.Controls. For most controls it just does control.BackColor = GetColor(control.BackColor) (mapping the design-time placeholder color to its themed equivalent), but it dispatches to IThemedControl.ApplyTheme(this) when a control implements that interface, and has special-cased branches for ComboBox, Label, PictureBox, ListView items, StatusStrip items, and DateTimePicker. It deliberately skips ListView and any ToolStrip/MenuStrip other than StatusStrip — those own their coloring entirely themselves (see below).
  2. LoadControls(Control.ControlCollection controls) — a local recursive function inside MoreForm.OnLoad that calls ((ILoadControl)child).OnLoad() on every descendant that implements ILoadControl, again parent-before-children, with no type filtering beyond the interface check. This is the hook simple controls that don't otherwise support an OnLoad (Button, Label, ...) use to do one-time themed setup at load — resolving their own colors, swapping an icon for its dark-mode variant, and so on.

 

Only MoreForm.OnLoad runs the ILoadControl walk. MoreUserControl.OnLoad calls manager.InitializeTheme(this) for its own Colorize pass but does not walk ILoadControl children itself — a MoreUserControl hosted inside a MoreForm gets its ILoadControl descendants themed by the host form's single top-level walk, not by the user control independently. SheetBase (the settings-sheet base class) is itself ILoadControl specifically so settings sheets hosted inside a container dialog participate correctly in that outer walk.

 

The two control-side contracts

 

IThemedControl (OneMore/UI/IThemedControl.cs) — for controls that need to resolve their own colors, optionally against per-instance overrides:

 

internal interface IThemedControl

{

  string ThemedBack { get; set; } // e.g. "ErrorText" for a validation field

  string ThemedFore { get; set; }

  void ApplyTheme(ThemeManager manager);

}

 

Colorize calls ApplyTheme(this) on any control that implements it, instead of doing its own default color assignment.

ILoadControl (OneMore/UI/ILoadControl.cs) — for controls (like Button and Label) that don't otherwise expose an extensibility point for one-time load logic:

 

internal interface ILoadControl

{

  Control.ControlCollection Controls { get; }

  void OnLoad(); // declared as: void ILoadControl.OnLoad() { }

}

 

The two are not mutually exclusive, and a control can route all of its real logic through one while leaving the other a near no-op stub just to satisfy Colorize's dispatch — MoreDataGridView implements both, with ApplyTheme doing nothing (// let OnLoad handle it) and all real theming logic in ILoadControl.OnLoad().

 

How individual More* controls theme themselves

 

There's no single required pattern — each control does whatever its underlying WinForms base class needs:

 

  • MoreButtonILoadControl only. Fully owner-drawn
    (
    ControlStyles.UserPaint); OnLoad() resolves BackColor/ForeColor respecting ThemedBack/ThemedFore, and if StylizeImage is set, runs its Image through an inverting ImageEditor when manager.DarkMode. OnPaint picks background/border colors directly from manager based on hover/pressed/focus state.
  • MoreTextBoxILoadControl only. OnLoad() sets ForeColor/
    BackColor from ThemedFore/ThemedBack (falling back to "WindowText"/"Window"), and re-runs that same logic from OnEnabledChanged so toggling Enabled re-themes the control live (grayed background/text) without a full reload.
  • MoreListView — implements neither interface, and is
    explicitly skipped by
    Colorize. It's fully owner-drawn instead (OwnerDraw = true), pulling colors directly from manager.GetColor(...) in its DrawColumnHeader/DrawItem/DrawSubItem handlers, with configurable SelectedBackColorKey/SelectedForeColorKey properties (default "Highlight"/"HighlightText"). It also pushes the native LVM_SETBKCOLOR message on handle creation, since WinForms ListView doesn't forward BackColor to the blank area below the last row.
  • MoreDataGridView — both interfaces; ApplyTheme is a deliberate
    no-op,
    ILoadControl.OnLoad() does the real work (BackgroundColor, ForeColor, GridColor from "WindowFrame", header/row/cell DefaultCellStyle colors, EnableHeadersVisualStyles = false).
  • MoreMenuStrip / MoreToolStripILoadControl only, and also
    skipped by
    Colorize (any ToolStrip/MenuStrip other than StatusStrip). They instead supply a custom ToolStripProfessionalRenderer at construction, built on ThemedColorTable (OneMore/UI/ThemedColorTable.cs), a ProfessionalColorTable override that pulls "MenuBar", "MenuHighlight", "MenuMargin", "MenuSeparator", "WindowFrame" from ThemeManager.Instance. MoreMenuItem/MoreToolStripButton/ MoreSplitButton override their Image setter to auto-invert icons through ImageEditor whenever manager.DarkMode — the general pattern for icon-per-theme without maintaining a separate dark asset for most toolbar/menu glyphs.

 

OnThemeChange()

 

public virtual void OnThemeChange() { } // MoreForm and MoreUserControl

 

Called from ThemeManager.InitializeTheme(ContainerControl), but only when DarkMode is true — light-themed dialogs never get it invoked. It's a load-time hook, not a live-update hook (see "No live theme switching" above): it fires exactly once, from OnLoad, before Colorize runs. It exists for the case a static color assignment can't cover — controls holding cached, expensive-to-recreate Brush/Pen/ Image state for owner-drawn rendering. The one real override in the codebase, SearchResultsCardView.OnThemeChange, reallocates its cached SolidBrush/Pen objects from manager.GetColor(...) for its owner-drawn card view.

 

Beyond WinForms

 

  • Ribbon iconsAddinRibbon.GetRibbonImage(string imageName),
    bound as the ribbon XML's
    loadImage callback, checks Office.IsBlackThemeEnabled(true) and, if dark, looks for a resource named $"Dark{imageName}" before falling back to the plain imageName. Unlike the toolstrip/menu pattern above, ribbon icons use explicitly authored dark variants, not runtime inversion — there's no single owner-drawn surface to hook into for the ribbon.
  • OneNote page content — not touched by ThemeManager at all. Any
    page-color or ink-color remapping for dark/light canvas is a distinct, page-content-focused concern handled elsewhere (
    PageColorCommand, PageColors, ColorExtensions) and documented in TechNote - Colors.
  • WebViewDialog — no dark-mode CSS/JS injection. WebView2 content
    renders exactly as authored;
    ThemeManager has no involvement.

 

References

 

  • OneMore/UI/ThemeManager.cs — singleton, ThemeMode, LoadColors,
    InitializeTheme, Colorize, HasCustomTheme
  • OneMore/UI/IThemedControl.cs, ILoadControl.cs
  • OneMore/UI/DarkTheme.json, LightTheme.json — built-in palettes
  • OneMore/UI/ThemedColorTable.csProfessionalColorTable for
    menu/toolstrip renderers
  • OneMore/UI/MoreButton.cs, MoreTextBox.cs, MoreListView.cs,
    MoreDataGridView.cs, MoreMenuStrip.cs, MoreToolStrip.cs
  • OneMore/Helpers/Office/Office.csIsBlackThemeEnabled,
    SystemDefaultDarkMode, DarkModeLightsOn
  • OneMore/Commands/Settings/GeneralSheet.cs — the only in-app theme
    picker (System/Light/Dark)
  • OneMore/Ribbon/AddinRibbon.csGetRibbonImage, dark ribbon-icon
    fallback convention
  • TechNote - Colors
    OneNote's own page/ink color remapping; Office theme registry values
  • Design - UI Layer
    MoreForm/MoreUserControl, modal/modeless mechanics this builds on

 

Note: OneMoreCalendar, the companion tray app, has its own separate theming stack (ThemeProvider, ThemedForm, ThemedUserControl) with a working in-app theme editor — structurally similar but independent code, out of scope here.

 

 

#omwiki #omdeveloper #omdesign

 

© 2021 Steven M Cohn. All rights reserved.

Please consider a sponsorship or one-time donation to support ongoing development

 

Created with OneNote.